iT邦幫忙

2024 iThome 鐵人賽

DAY 27
1
Software Development

可以Go一輩子嗎?系列 第 27

專案練習: 字幕搜尋API (1/4)

  • 分享至 

  • xImage
  •  

專案練習: 字幕搜尋API (1/4)

終於到了專案練習的部分,這次的專案是要做一個字幕搜尋的API,這個API可以透過關鍵字搜尋字幕,並且可以透過API取得字幕的內容。這個專案會分成四天,第一天的任務是將字幕資料導入到資料庫中。

為了著重於Go語言實作的部份以及節省時間,這邊我的資料會先拿別人已經整理好的資料(MYGO 網站時間軸更新 @場外休憩區 哈啦板 - 巴哈姆特),不過基本上只要確定你的字幕資料中有包含字幕的內容、影片集數、開始時間、結束時間等資訊即可。

專案資料夾結構

mygo
├── data
│   ├── data.go
│   ├── db.go
│   ├── go.mod
│   └── go.sum
├── data.json
├── go.mod
├── go.sum
└── main.go

首先建立mygo資料夾,並運行go mod init mygo初始化module,然後在mygo資料夾中建立data資料夾,並在data資料夾中初始化module為mygo/data,接著在mygo資料夾的go.mod中加入replace來指定data資料夾的module。初始化結束後將你的資料與main.go放在相同的資料夾中

  • mygo/go.mod
module mygo
go <current version>
replace mygo/data => ./data

require mygo/data v0.0.0

JSON 資料格式

{
	"result": [
		{
			"episode": "1",
            "start": 24,
            "end": 48,
            "text": "text1"
			"segment_id": 1
		}
        // ...
    ]
}

資料庫架構

CREATE TABLE sentence (
	id INT PRIMARY KEY AUTO_INCREMENT,
	text TEXT NOT NULL,
	episode VARCHAR(3) NOT NULL,
	frame_start INT NOT NULL,
	frame_end INT NOT NULL,
	segment_id INT NOT NULL UNIQUE
);

讀取資料

我這邊的資料是一個JSON中包著list of object, 每個object中包含了影片集數、開始時間、結束時間、字幕內容等資訊,其中開始時間和結束時間以frame為單位。如果你的資料格式跟我這邊不一樣,請自行修改code或資料格式
首先我們先建立一個資料夾data, 接著透過go mod init初始化module

mkdir data
go mod init data

接著我們將資料處理成list of struct的格式,這樣之後才能將資料寫入到資料庫中。資料庫的部份我們透過gorm來處理,所以我們可以在定義JSON struct同時定義資料庫的struct

package data

import (
	"encoding/json"
	"os"
)

type SentenceItem struct {
	Text       string `json:"text" gorm:"type:text;not null"`
	Episode    string `json:"episode" gorm:"type:varchar(3);not null"`
	FrameStart int    `json:"frame_start" gorm:"not null"`
	FrameEnd   int    `json:"frame_end" gorm:"not null"`
	SegmentId  int    `json:"segment_id" gorm:"primaryKey"`
}

type Sentence struct {
	Result []SentenceItem `json:"result"`
}

func GetDataFromFile() Sentence {
	text, err := os.ReadFile("data.json")
	if err != nil {
		panic(err)
	}
	sentenceData := Sentence{}
	err = json.Unmarshal(text, &sentenceData)
	if err != nil {
		panic(err)
	}

	return sentenceData
}

接下來就是處理資料庫初始化的部份,這邊我們透過gorm來處理資料庫架構的建立與初始化,因為我們在前面有指定struct tag,所以gorm會自動幫我們建立資料庫(這邊以MariaDB為例)

var Database *gorm.DB
const dsn = "root:root@tcp(localhost:3306)/mygo?charset=utf8&parseTime=True&loc=Local"
func CreateDB() *gorm.DB {
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Silent),
	})
	if err != nil {
		panic(fmt.Sprintf("failed to connect to database: %v", err))
	}
	if err := db.AutoMigrate(&SentenceItem{}); err != nil {
		panic(fmt.Sprintf("error migrating schema: %v", err))
	}
	return db
}

接下來我們將資料透過db.Create寫入到資料庫中,透過db.Create可以一次寫入單筆或多筆資料

var sentenceData = GetDataFromFile()
var result = Database.Create(&sentenceData.Result)
if result.Error != nil {
	panic(result.Error)
}

db.Create有一個缺點就是如果資料庫中已經有相同的資料,會無法導入(Unique Key),所以我們可以透過針對每一筆資料進行檢查,如果資料庫中已經有相同的資料(基於segment_id),就不進行寫入。

func coorInsert(wg *sync.WaitGroup, item SentenceItem, db *gorm.DB, messageChan chan<- string) {
	defer wg.Done()

	// Check if the record already exists
	var existingItem SentenceItem
	err := db.First(&existingItem, "segment_id = ?", item.SegmentId).Error
	if err == nil {
		messageChan <- fmt.Sprintf("Segment ID(%d) already exists", item.SegmentId)
		return
	} else if err != gorm.ErrRecordNotFound {
		messageChan <- fmt.Sprintf("Error checking Segment ID(%d): %v", item.SegmentId, err)
		return
	}

	// Insert the new record
	if err := db.Create(&item).Error; err != nil {
		messageChan <- fmt.Sprintf("Error inserting Segment ID(%d): %v", item.SegmentId, err)
		return
	}
	messageChan <- fmt.Sprintf("Successfully inserted Segment ID %d", item.SegmentId)
}

func insertOrUpdate(sentenceData *Sentence, Database *gorm.DB, messageChan chan string, wg *sync.WaitGroup) error {
	for _, item := range sentenceData.Result {
		wg.Add(1)
		go coorInsert(wg, item, Database, messageChan)
	}

	// wait until all insertItem goroutines are done, then close channel
	go func() {
		wg.Wait()
		close(messageChan)
	}()

	// dump all messages to stdout
	for message := range messageChan {
		fmt.Println(message)
	}
	return nil
}

執行完後檢查資料庫是否有成功寫入資料

SELECT * FROM sentence LIMIT 10;
DESCRIBE sentence;

Image

最後的Code如下(插入或更新)

這邊的插入或更新是透過檢查資料庫中是否已經有相同的資料,如果有就不進行寫入,這邊我們透過goroutine來進行並行寫入,並且透過channel來傳遞訊息,最後再將訊息印出來。
剛開始寫的時候沒有注意到連線數的問題,所以這邊我們透過SHOW VARIABLES LIKE 'max_connections'來取得最大連線數,並且預留20個連線數給應用程式,這樣就不會因為連線數不足而無法寫入資料。接著初始化一個channel buffer來限制並行寫入的數量,這樣就不會因為太多連線而導致資料庫連線數過高。最後透過sync.WaitGroup來等待所有goroutine都完成後再關閉channel。

  • mygo/data/data.go
package data

import (
	"encoding/json"
	"os"

	"gorm.io/gorm"
)

// SentenceItem represents a sentence item with GORM model tags
type SentenceItem struct {
	ID         uint   `gorm:"type:int;primaryKey;autoIncrement;not null;"`
	Text       string `json:"text" gorm:"type:text;not null;"`
	Episode    string `json:"episode" gorm:"type:varchar(3);not null;"`
	FrameStart uint   `json:"frame_start" gorm:"type:int;not null;"`
	FrameEnd   uint   `json:"frame_end" gorm:"type:int;not null;"`
	SegmentId  uint   `json:"segment_id" gorm:"type:int;not null;index;unique;"`
}

// TableName sets the insert table name for this struct type
func (SentenceItem) TableName() string {
	return "sentence"
}

type Sentence struct {
	gorm.Model
	Result []SentenceItem `json:"result"`
}

func GetDataFromFile() Sentence {
	text, err := os.ReadFile("data.json")
	if err != nil {
		panic(err)
	}
	sentenceData := Sentence{}
	err = json.Unmarshal(text, &sentenceData)
	if err != nil {
		panic(err)
	}
	return sentenceData
}
  • mygo/data/db.go
package data

import (
	"fmt"
	"strconv"
	"sync"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var Database *gorm.DB

const dsn = "ithome:ironman@tcp(localhost:3306)/mygo?charset=utf8&parseTime=True&loc=Local"

func CreateDB() *gorm.DB {
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Silent),
	})
	if err != nil {
		panic(fmt.Sprintf("failed to connect to database: %v", err))
	}
	if err := db.AutoMigrate(&SentenceItem{}); err != nil {
		panic(fmt.Sprintf("error migrating schema: %v", err))
	}
	return db
}

func getMaxConnections() (int, error) {
	var value string
	row := Database.Raw("SHOW VARIABLES LIKE 'max_connections'").Row()
	err := row.Scan(new(string), &value)
	if err != nil {
		return 0, err
	}
	maxConnections, err := strconv.Atoi(value)
	if err != nil {
		return 0, err
	}
	return maxConnections, nil
}

func coorInsert(wg *sync.WaitGroup, semaphore chan struct{}, item SentenceItem, db *gorm.DB, messageChan chan<- string) {
	defer wg.Done()
	semaphore <- struct{}{}
	defer func() { <-semaphore }()

	// Check if the record already exists
	var existingItem SentenceItem
	err := db.First(&existingItem, "segment_id = ?", item.SegmentId).Error
	if err == nil {
		messageChan <- fmt.Sprintf("Segment ID(%d) already exists", item.SegmentId)
		return
	} else if err != gorm.ErrRecordNotFound {
		messageChan <- fmt.Sprintf("Error checking Segment ID(%d): %v", item.SegmentId, err)
		return
	}

	// Insert the new record
	if err := db.Create(&item).Error; err != nil {
		messageChan <- fmt.Sprintf("Error inserting Segment ID(%d): %v", item.SegmentId, err)
		return
	}
	messageChan <- fmt.Sprintf("Successfully inserted Segment ID %d", item.SegmentId)
}

func insertOrUpdate(sentenceData *Sentence, Database *gorm.DB, messageChan chan string, wg *sync.WaitGroup) error {
	maxConnections, err := getMaxConnections()
	if err != nil {
		return fmt.Errorf("failed to get max_connections: %v", err)
	}
	reservedConnections := 20 // reserve 20 connections for the application
	maxConnections -= reservedConnections
	if maxConnections <= 0 {
		return fmt.Errorf("no available connections for the application")
	}

	sqlDB, err := Database.DB()
	if err != nil {
		return fmt.Errorf("failed to get DB instance: %v", err)
	}
	sqlDB.SetMaxOpenConns(maxConnections)
	sqlDB.SetMaxIdleConns(maxConnections)
	sqlDB.SetConnMaxLifetime(0)

	semaphore := make(chan struct{}, maxConnections) // setup a semaphore to limit concurrency
	for _, item := range sentenceData.Result {
		wg.Add(1)
		go coorInsert(wg, semaphore, item, Database, messageChan)
	}

	// wait until all insertItem goroutines are done, then close channel
	go func() {
		wg.Wait()
		close(messageChan)
	}()

	// dump all messages to stdout
	for message := range messageChan {
		fmt.Println(message)
	}
	return nil
}
func init() {
	messageChan := make(chan string)
	var wg sync.WaitGroup

	Database = CreateDB()

	sentenceData := GetDataFromFile()
	err := insertOrUpdate(&sentenceData, Database, messageChan, &wg)
	if err != nil {
		panic(err)
	}
}

那麼今天的文章就到這告一段落,如果我的文章有任何地方有錯誤請在留言區反應
明天將會實作透過FFmpeg輸出指定frame的圖片或GIF
time

Reference

參考資料:


上一篇
Day26. 透過SQL語法與ORM操作資料庫
下一篇
專案練習: 字幕搜尋API (2/4)
系列文
可以Go一輩子嗎?31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言